前一階段已經完成 Google OAuth 2.0 授權,也成功透過 Calendar API 建立行程。接下來,我想把這條流程整理成可以持續擴充的 Discord Bot。
這次將 Day 20~22 合併成一篇紀錄,實作仍分成三個階段:API 模組化、日期時間驗證,以及新增結果與錯誤處理。
我的完成標準是:程式寫好之後,還要測試成功。因此,除了自動測試,我也實際在 Discord 輸入 /add,再到 Google 日曆確認結果。
| 天數 | 主題 | 完成內容 |
|---|---|---|
| Day 20 | Google Calendar API 模組化 | 將 API 操作獨立成 Service,讓 Discord 指令呼叫 |
| Day 21 | 日期、時間與時區驗證 | 檢查格式、真實日期、過去時間、台灣時區與跨日 |
| Day 22 | 回覆與錯誤處理 | 顯示結果與連結,處理 API 失敗、授權失效和重複提交 |
原本 /add 同時負責讀取 Discord 選項、驗證資料,以及呼叫 Google API。功能增加後,這個檔案會越來越難維護。
這次把程式分成幾個部分:
src/
├── commands/
│ ├── add.js # 讀取指令、驗證與回覆
│ └── help.js
├── services/
│ └── calendarService.js # 新增、查詢確認與錯誤分類
├── eventTime.js # 日期、時間及時區驗證
├── googleCalendar.js # OAuth 與 Calendar client
└── index.js # Bot 啟動與指令分派
calendarService.js 封裝 createEvent(),接收已驗證的行程資料,再透過 events.insert() 建立事件。Discord 指令只需要取得結果,不必知道 Google API 的細節。
以下是 add.js 呼叫 Service 的核心片段:
result = await service.createEvent(
input,
`${interaction.guildId || 'dm'}:${interaction.user.id}`
);
第二個參數用來區分伺服器與使用者,後續也會用在重複提交判斷。

圖 1:VS Code 中的模組化結構,以及 Calendar Service 的 createEvent()。
Calendar client 會讀取既有的 token.json,並讓 Google 授權套件處理 Token 更新,不需要每次新增行程都重新開啟授權頁面。若授權遭撤銷或失效,才提示管理員重新授權。
這次先確認既有 Token 可用,再直接從 Discord 測試新增:
/add title:Day20-22_Taipei_CrossDay date:20261020 time:2330
Discord 回覆新增成功,也提供了 Google Calendar 的事件連結。接著使用 API 讀回事件,並開啟日曆網頁確認,確保行程真的寫進去。

圖 2:Discord 實際執行 /add 成功,顯示日期、開始與結束時間、時區及事件連結。
20260230 雖然是八位數,卻不是有效日期。因此,我把驗證分成幾個層次:
| 驗證項目 | 規則或範例 |
|---|---|
| 日期格式 | 必須是八位數 YYYYMMDD |
| 真實日期 | 拒絕 20260230、20261301,並檢查閏年 |
| 時間格式 | 必須是四位數 HHmm,採用 24 小時制 |
| 時間範圍 | 拒絕 2460、2560 等不存在的時間 |
| 過去時間 | 比較完整時間點,拒絕小於或等於現在的時間 |
| 行程名稱 | 去除首尾空白,長度須為 1~100 個字元 |
驗證失敗就直接回覆錯誤,不會繼續呼叫 Calendar Service。
我在 Discord 實際測試了不存在的日期、過去時間和錯誤時間,三種情況都被正確擋下。

圖 3:Discord 拒絕錯誤日期、過去時間及 2460,沒有將錯誤輸入送去新增行程。
Asia/Taipei日期時間不能依賴執行電腦的預設時區,否則換到其他伺服器後,行程可能會偏移。
這次使用 Luxon,明確以 Asia/Taipei 解讀輸入,再產生包含 UTC offset 的時間字串。API 回傳的時間也會轉回台灣時間,統一顯示在 Discord。
const start = DateTime.fromObject(parts, {
zone: 'Asia/Taipei'
});
const end = start.plus({ hours: 1 });
目前每筆行程固定持續一小時,所以跨日也必須正確處理。例如這次的實測:
開始:2026-10-20 23:30
結束:2026-10-21 00:30
時區:Asia/Taipei(UTC+08:00)

圖 4:從已建立的 Google 日曆事件開啟詳細資料,可確認跨日的開始、結束時間與 GMT+08:00 台北標準時間。
台灣目前沒有實施夏令時間,但日期驗證模組仍加入相關防護:遇到時鐘向前調整而不存在的時間,或向後調整而重複出現的時間,都會拒絕輸入。
這部分使用 America/New_York 的時區案例做自動測試;實際 /add 仍固定採用台灣時區。測試也在不同系統時區下執行,確認結果不受電腦設定影響。
Google API 需要一些處理時間,因此指令先使用 deferReply(),完成後再更新回覆。
成功訊息統一顯示行程名稱、日期、開始與結束時間、時區,以及 API 回傳的 htmlLink。回覆設定為僅提交者可見。
只有 API 新增成功,或確實查到已存在的事件,才會顯示對應結果。如果無法確認是否寫入,就明確告知使用者目前無法確認,不會直接回覆「新增成功」。
Service 會分類授權、權限、配額、資料與網路等錯誤,再由 Discord 指令顯示容易理解的訊息。Google 原始錯誤物件不會直接貼到 Discord,避免夾帶敏感資訊。
為了測試真實錯誤回覆,我使用獨立的驗證程式,刻意安排兩個情境:
這兩種情況都有實際呼叫 Google API,沒有刪除或撤銷原本的 token.json 授權。網路異常、權限不足、配額限制及伺服器錯誤等其他分支,則由模擬 API 自動測試驗證。

圖 5:刻意觸發 Google API 與授權錯誤後,Discord 顯示對應提示,沒有洩漏 Token。
如果使用者按太快,或遇到網路延遲後重試,單純再次呼叫 events.insert() 可能會產生重複行程。
這次使用伺服器、使用者、標題、開始與結束時間等資料產生固定事件 ID。同一使用者在同一伺服器提交完全相同的內容,會對應到同一個事件。
處理方式如下:
我在重啟 Bot 後,再送出相同的跨日行程。Discord 顯示「此行程已存在,未重複新增」,Google API 也確認只有一筆。

圖 6:再次提交相同行程時,Bot 回傳既有事件及連結,沒有新增第二筆。
這個規則以「相同內容」為準;修改標題或時間會視為另一筆行程。它也不會自動辨識舊版程式或手動建立的相同行程。
另外,Google 日曆可能將跨日事件分別顯示在兩天,標示「第 1 天/第 2 天,共 2 天」。那是同一筆事件跨兩天顯示,不代表新增了兩筆。
錯誤回覆正常還不夠,我也確認失敗後的下一次操作能成功。
在完成故障測試後,再新增 Day22_Recovery_AfterFailure,Discord 回覆成功,Google 日曆也確實出現該事件。

圖 7:失敗測試後仍能新增行程,並取得新的事件連結。
接著再次觸發錯誤並執行 /ping,Bot 仍正常回覆 Pong!。

圖 8:API 與授權錯誤沒有讓 Bot 結束,後續 /ping 仍可使用。
這三天讓我更清楚:串接 API 不只是把資料送出去,還要先確認輸入合理、正確處理時區,並在成功或失敗時給使用者明確的結果。